feat(policy): per-rule scanModalities for content-scanning conditions#12
Merged
feat(policy): per-rule scanModalities for content-scanning conditions#12
Conversation
Previous design (closed in cloud PR #4) put modality config on a global org-level toggle. That coupled unrelated policies and made no sense for metadata-only rules (rate_limit, token_budget, kill_switch). Each policy that semantically scans content should declare its own modality coverage. This commit lays the SDK groundwork for that — cloud-side UI + enforce wiring lands in a separate PR. Added - `scanModalities?: Modality[]` on `PolicyRule`. Optional, defaults to legacy behaviour. Only meaningful when the condition type is one of the six content-scanning types (`injection_guard`, `ml_injection_guard`, `blocklist`, `input_pattern`, `output_pattern`, `sensitive_data_filter`). - `textByModality?: Partial<Record<Modality, string>>` on `EnforcementContext`. Host populates with pre-extracted text (typically by calling `scanMultiModal()` once for the union of modalities across active rules) before invoking enforce(). - `CONDITIONS_SUPPORTING_MODALITIES` (ReadonlySet) + `conditionSupportsModalities(type)` exported from `governance-sdk/scan/multi-modal`. The cloud's policy editor consults this to decide whether to render a modality selector for a given rule; validators reject `scanModalities` on rule types not in the set. - `getScanText(ctx, rule)` exported from `governance-sdk` (top-level via policy.ts re-export). Returns per-modality text slices when the rule opts in, or `null` to signal "use the legacy input-walk fallback." This is the backward-compat seam. Changed - `ConditionEvaluator` widened to `(ctx, params, rule?) => boolean`. Structurally backward-compatible — existing `(ctx, params) => boolean` implementations still satisfy the wider signature. - Engine threads the rule through `evaluateCondition`, `evaluate`, and `evaluateStage` so evaluators can read `rule.scanModalities`. - Six content-scanning evaluators updated to consult `getScanText(ctx, rule)` and fall back to their original behaviour when null. Legacy rules without `scanModalities` see exactly the same content as before this feature shipped — verified by the existing 1,399 tests. - Combinators (`any_of`, `all_of`, `not`) synthesise a per-child rule view that preserves the parent's `scanModalities` while rebinding `condition` to the nested type. This lets `getScanText()` correctly evaluate the CHILD's eligibility while still scoping to the PARENT's modality config — so an `any_of` over `injection_guard` + `blocklist` with `scanModalities: ["image"]` works end-to-end. What this PR is NOT - Cloud's policy editor UI for the modality selector — separate PR. - Cloud's enforce orchestrator wiring (`scanMultiModal()` invocation + default extractor) — separate PR. - Cloud-side validator rejecting scanModalities on unsupported conditions at PATCH time — separate PR (uses the exported helper). Tests - 14 new tests in src/per-rule-modalities.test.ts covering the registry contents, getScanText null/non-null paths, per-rule dispatch to textByModality, modality scoping (text-only rule does NOT match on image content and vice versa), multi-rule independence, scanModalities ignored on non-content-scanning rules, and combinator propagation. - Full suite: 1,413 / 0 (was 1,399 / 0). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The previous shape (closed in governance-cloud#4) put modality config on a global org-level toggle. That coupled unrelated policies and was nonsensical for metadata-only rules (rate_limit, token_budget, kill_switch).
Each policy that semantically scans content should declare its own modality coverage. A
prompt_injectionrule might want['text', 'image']; a separatesensitive_data_filterrule might want only['text']. Per-policy granularity, single shared OCR pass per request, no hidden coupling.This PR is the SDK groundwork for that. Cloud-side UI + enforce-orchestrator wiring lands in a follow-up that imports the new types and helpers.
API surface
What changes in the engine
ConditionEvaluatorwidened to(ctx, params, rule?) => boolean. Structurally backward-compatible — existing(ctx, params) => booleanimplementations satisfy the wider signature unchanged.evaluateCondition,evaluate,evaluateStagethread the rule through so evaluators can readrule.scanModalities.getScanText(ctx, rule)and fall back to their original behaviour when it returnsnull.any_of,all_of,not) synthesise a per-child rule view: preserves parent'sscanModalities, rebindsconditionto the nested type. Soany_ofoverinjection_guard+blocklistwithscanModalities: ['image']correctly scopes both sub-checks to image text.Conditions that get
scanModalitiesinjection_guardml_injection_guardblocklistinput_patternoutput_patternsensitive_data_filterEverything else —
tool_blocked,cost_budget,concurrent_limit,time_window,agent_level,network_allowlist,scope_boundary,require_signed_identity,require_signed_action, length checks, combinators themselves — operates on metadata. The registry rejectsscanModalitieson those at the helper level so they remain inert when set.Backward compatibility
scanModalitiessee exactly the same content as before.getScanTextreturnsnull, the evaluators fall back toextractStrings(ctx.input)/ctx.outputText/ etc. — verified by the existing 1,399 tests passing unchanged.ConditionEvaluator's third parameter is optional. Custom evaluators registered by callers keep working.What's NOT in this PR (follow-ups)
conditionSupportsModalitiesfrom this PR.scanMultiModal()call per request for the union of modalities across active rules, populatesctx.textByModality, then invokesenforce(). Plus a default extractor (Groq vision is the easiest reuse — the client's already wired for Tier-3).scanModalitieson unsupported condition types at write time. Uses the exported helper.Tests
src/per-rule-modalities.test.ts(14 tests):getScanTextnull paths (no rule / unsupported condition / unset modalities) and non-null paths (per-modality slices, missing-modality skip)scanModalitiesignored on non-content-scanning rule (cost_budget proxy)any_ofwith parentscanModalities=['image']correctly catches a hit only when the target text is in the image modality)Test plan
npm run buildcleannpm test1,413 / 0src/policy.ts(PolicyRule, EnforcementContext, ConditionEvaluator, getScanText, evaluator-threading)src/conditions/builtins.ts(six evaluator wrappers + combinator child-rule synthesis)src/scan/multi-modal.tsis complete (no false negatives, no false positives)🤖 Generated with Claude Code
Note
Medium Risk
Changes the policy engine evaluation signature and content-scanning condition behavior to optionally read modality-specific text, which can alter enforcement decisions if hosts start populating
textByModality/scanModalitiesor if combinator propagation is incorrect.Overview
Adds per-rule modality scoping for content-scanning policies by introducing
PolicyRule.scanModalitiesandEnforcementContext.textByModality, plus a newgetScanText()helper to select the modality-specific text (or fall back to legacy input/output scanning when unset).Threads the parent
PolicyRulethrough condition evaluation (ConditionEvaluatornow acceptsrule?) and updates the six content-scanning built-ins (injection_guard,ml_injection_guarddocs,blocklist,input_pattern,output_pattern,sensitive_data_filter) to consultgetScanText(). Combinators (any_of/all_of/not) now propagate the parent rule’sscanModalitiesinto nested conditions.Exports
CONDITIONS_SUPPORTING_MODALITIESandconditionSupportsModalities()fromscan/multi-modal, and adds a focused test suite covering helper behavior, legacy fallback, per-rule dispatch, non-content-rule no-ops, and combinator propagation.Reviewed by Cursor Bugbot for commit 423e372. Bugbot is set up for automated code reviews on this repo. Configure here.